本文讨论异步模块规范(Asynchronous Module Definition,AMD)API,即 RequireJS 所支持的模块 API,
的设计驱使与使用方式。另有页面讨论 Web 模块化的基本方式。
模块目的
什么是 JavaScript 模块?它们的目的是什么?
- 定义:如何把一段代码封装成一个有用的单元,以及如何注册此模块的能力、输出的值
- 依赖引用:如何引用其它代码单元
现今 Web
现如今 JavaScript 代码段是如何定义的呢?
- 通过立即执行的工厂函数定义。
- 使用 HTML
script
标签加载模块,通过全局变量来引用依赖。
- 模块间依赖的声明很弱:开发者需要知道正确的依赖顺序。例如,包含 Backbone 的文件,不能放在 jQuery 标签之前。
- 优化部署的时候,需要用额外的工具来把一系列
script
标签替换成一个。
这些都会使大型项目变得难以管理,尤其是当脚本们的诸多依赖还可能重叠、嵌套的时候。
手工写 script
标签可不怎么灵活,而且这么做就没法搞按需加载了。
CommonJS
最初的 CommonJS 小组 的参与者们决定弄一份于时下的
JavaScript 编程语言有效,但不必束缚于浏览器 JS 环境的限制,的模块规范。开始的愿景是在浏览器里使用一些权宜之计,
并希望能借此影响浏览器厂商,促使它们为这种模块规范的原生支持提供解决方案。权宜之计有:
- 要么使用一个服务来转译 CJS 模块成浏览器中可用的代码
- 要么使用 XMLHttpRequest(XHR)以文本形式加载模块,再在浏览器中做文本变换、解析的工作
CJS 模块规范仅允许每文件一个模块,所以为优化、打包,可使用某种“转换格式”将多个模块合并到单个文件。
通过这种方式,CommonJS 小组搞定了依赖引用、如何处理循环依赖,以及如何获得当前模块的某些属性等问题。
但是,他们并没能接纳浏览器环境里不可改变、并且仍将影响模块设计的某些特性:
这也同时意味着他们为了实现这个规范,将负担更多地放到了 Web 开发者身上,而这些权宜之计也使调试变得更麻烦。
调试 eval 的代码,或者调试多个文件合并之后的单个文件,都有实际使用时的坏处。
这些缺点或许在未来某天会被浏览器调试工具解决掉,但结论仍然是:在最普遍的 JS 环境,浏览器中,使用 CommonJS 模块并不是最好的办法
AMD
AMD 规范的缘起是,我们需要一个比时下那种“写一堆 script
标签、手工按序指明依赖”要好,并且容易在浏览器中直接使用的模块格式。
某种不需要服务端工具配合,又能够易于调试的模块格式。它超脱于 Dojo 使用 XHR+eval
的现实经验,
并且要规避 Dojo 的方式的缺点。
它比时下 Web 开发中“全局变量+script
标签”的方式要好,因为:
- 应用 CommonJS 实践,使用字符串 ID 来声明依赖。使得依赖声明清晰,并且避免了使用全局变量。
- 模块 ID 可以被映射到不同的路径。从而允许切换模块实现。这对创建单元测试模型很有帮助。
在上例中,代码实际期待的不过是某个实现了 jQuery API 与行为的模块而已。并不是非要 jQuery 本身不可。
- 封装了模块定义。使你能够避免污染全局命名空间。
- 清楚地定义模块输出。可以用
return value;
,也可以用 CommonJS 的 exports
范式。
后者在循环依赖的时候很有用。
它是 CommonJS 模块规范的改善,因为:
- 它在浏览器里跑得更好,它的坑是最少的。其他的解决方案,都有调试、跨域、CDN 使用,
file://
协议以及依赖服务端工具等问题。
- 定义了将多个模块包含进单个文件的方式。在 CommonJS 的条款里,有个“传输规范”来做这个事情,
但 CommonJS 小组还没就此达成一致
- 允许将函数作为返回值。这在模块返回值是构造函数的时候尤其有用。在 CommonJS 中就有点尴尬了,
永远要通过给
exports
对象设置属性来输出。Node 支持了 module.exports = function() {}
,
不过这还没在 CommonJS 规范里面。
模块定义
使用 JavaScript 的函数进行封装,已经有
文档 约定了:
这种模块依赖于给全局对象附加属性来输出模块值,并且用这种模型很难声明模块依赖。
预设是,模块的依赖在执行此函数之前就已经是立即可用的。这限制了加载依赖的策略。
AMD 通过如下手段解决了这些问题:
- 调用
define()
来注册工厂函数,而不是立即执行该函数。
- 以字符串数组的形式将依赖传递进来,而不是直接从全局对象上取。
- 在所有依赖都被加载、执行完毕之后,才执行工厂函数。
- 将依赖的模块作为执行工厂函数的参数。
命名的模块
请注意,上例中的模块并没有给自己取名。这使得这个模块可以随便移动。它允许开发者将模块放到不同的目录,
从而管它们叫不同的名字。AMD 加载器会根据它被其他脚本引用的方式来给这个模块标记 ID。
但是,为了提高性能,打包多个模块的工具需要有个给单个文件中的每个模块命名的方式。对此需求,AMD
允许以字符串作为 define()
的第一个参数:
你应该避免自行命名模块,并且在开发时保持每文件一个模块。只不过,在注重性能的阶段,
得有个模块规范以在编译后的资源中命名模块。
语法糖
上面的 AMD 例子在所有浏览器中都能跑。但是,有搞错模块数组与参数顺序的风险,尤其是当你的模块依赖了好多的时候:
简而为之,同时也让封装 CommonJS 模块更容易,还支持这种形式的 define()
(有时这种封装还被称作“简化 CommonJS 封装”):
AMD 加载器会使用 Function.prototype.toString()
来解析出所有的 require('')
调用,
然后在内部将上述 define
调用转换成这种形式:
如此,加载器会异步加载依赖一(dependency1)与依赖二(dependency2),执行这些依赖,然后执行这个工厂函数。
并非所有的浏览器都给出可用的 Function.prototype.toString()
结果。自2011年10月,
PS3 和老旧的 Opera Mobile 浏览器的返回结果就不准确。这些浏览器也更可能因网络与设备的限制,
需要编译优化模块代码,所以直接用个懂得如何解析、转换正确的依赖数组的优化工具来打包优化一下就好,
例如 RequireJS 优化工具。
因为不支持 toString()
解析的浏览器相当少,用这种语法糖形式来编写你的模块是安全可靠的。
特别是当你喜欢把依赖按照它们的名字排列整齐的时候。
CommonJS 兼容性
虽然这个语法糖形式被称作“简化 CommonJS 封装”,但它并非与 CommonJS 模块 100% 兼容。不过,CommonJS
模块一般认为依赖是同步加载的,完全兼容它的话,在浏览器里就会挂掉啦。
绝大部分 CJS(CommonJS)模块,根据个人经验(不太科学地毛估估)大约有 95%,是与简化 CommonJS 封装完美兼容的。
不兼容的模块,都是那些依赖是动态计算的,调用 require()
的时候不用字符串字面量的,以及其他不像个声明方式调用 require()
的。
所以,下边这种会挂:
这些使用方式,在 require
回调 中支持,
即 AMD 加载器中提供的全局的
AMD 的执行模式比较向 ECMAScript 和谐版(Harmoney)的模块规范所约定的看齐。
CommonJS 模块规范在 AMD 中的不兼容部分在和谐版(Harmoney)模块也一样不兼容。
AMD 的代码执行逻辑的未来兼容性更好。
冗长与可用性
对 AMD 的批评其一,至少与 CJS 模块规范相比,是它要求一层缩进,以及一个函数封装。
但事实很简单:要用 AMD 就觉得需要多打点字并多缩进一层,其实这一点都没关系。你编程的时间是这么花的:
绝大部分编程的时间是花在思考而非敲代码上的。虽然代码一般越短越好,但所能付出的代价终归是有限的,
何况用 AMD 要再打些的字其实也没那么多。
而且大部分 Web 开发者本来就在用匿名函数封装了,目的是避免污染全局变量。看到逻辑代码外边包了匿名函数,其实是很普遍的,
并不会给阅读模块代码带来困扰。
同时,CommonJS 格式还有些潜在代价:
- 依赖工具的代价
- 某些边界用例在浏览器中会挂,比如跨域访问
- 调试更差,此代价随着时间增加,会越来越大
AMD 模块规范需要的工具更少,边界用例问题更少,调试支持也更好。
重要的是:能够真正与其他人分享代码。AMD 规范是达此目标更轻松的方式。
拥有一个可用的,易于调试,并且还在现如今的浏览器中能跑的模块系统,意味着在创造未来的 JavaScript
中最好的模块系统的同时,得到现实世界的体验。
AMD 和它相关的 API 们,已经帮助展示了任何未来 JS 的模块系统都具备的以下特性:
- 返回一个函数作为模块值,尤其是构造函数,促使更好的 API 设计。Node 有
module.exports
来支持此特性,
但能够用 return function() {}
的话会更简洁。这意味着不需要持有 module
以 module.exports
,
并且代码表达也更清晰。
- 动态代码加载(AMD 模块系统中通过
require[],function(){})
来实现)
是基本要求。CJS 小组谈到了这个问题,提了一些议案,但它并没有广为接受。Node 并不支持这个需求,转而依赖 require()
的同步行为,
而这在 Web 端是无从实现的。
- 加载器插件 相当有用。它帮助避免基于回调的编程中常遇到的嵌套缩进的问题。
- 选择映射某个模块 从另一个地址加载,使得提供模块模拟对象以供测试变得容易。
- 每个模块最多一个 IO 行为,并且这个 IO 行为应该简单直接。Web 浏览器受不了找个模块还有多次 IO 查找。
这和 Node 里现在的多次寻址是相悖的,并且避免使用一个有
main
属性的 package.json
。
用能够很容易地根据项目目录结构映射到某个地址的模块名,一个合理的不需要冗长的配置的约定就够了,不过要允许必要的简单配置。
- 最好有个“选择加入”调用,用来使旧 JS 代码快速参与新的模块系统之中。
如果一个 JS 模块系统不能搞定以上特性,那么与 AMD 和它相关的 API 们,例如
callback-require、
加载器插件 和基于路径的模块 ID,相较之下,高下立见。
AMD 使用情况
截至 2011年10月,AMD 已经在 Web 上广为使用:
你可以做什么
如果你是写应用的:
- 试试 AMD 加载器。你有以下可选:
- 如果你想要用 AMD 不过仍然采用在 HTML 页尾放一个
script
节点的方式:
如果你是脚本库开发者:
- 如果可用,条件调用
define()
。妙处在于不依靠 AMD 你仍然可以编写你的库,只要可用的时候参与一下就可以了。
这使得你的模块用户可以:
- 避免往页面全局变量里头塞东西
- 代码加载、延迟加载等更加有选择
- 用现有的 AMD 工具来优化它们的项目
- 参与到当今浏览器中可用的 JS 模块系统中去
如果你为 JavaScript 加载器、引擎、环境编写代码:
- 实现 AMD API。有个
讨论列表 和
兼容性测试。通过实现 AMD 模块规范,你可以减少多模块系统的无谓竞争,
帮助为一个 Web 端可用的 JavaScript 模块系统证明。这也能反馈到 ECMAScript 工作组,以求更好的本地模块系统支持。
- 也要支持 callback-require 和
加载器插件。
加载器插件是个很好的减轻回调、异步风格的代码中常见的嵌套回调症的方式。